iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 23 篇

Day 16(上)|平均 Latency 為什麼會騙人?請看尾端

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:平均 latency 能掩蓋少數非常慢的請求;互動服務要同時看分布、P50、P95 或 P99,才能看見排隊和下游抖動。

九十五個請求各 100 ms,五個各 10 秒,平均約 595 ms。看起來勉強可以,實際上有五個使用者已經以為網路壞掉。percentile 不是魔法,但它比平均值更接近「最慢那群人正在等多久」。

Google SRE Book 用過一個經典例子:「如果一個網路服務的平均 latency 是 100 ms,每秒 1000 個請求,那 1% 的請求很容易就需要 5 秒。」簡單算術跟現實體驗差了一大截。再往下想,在分散式系統中,當使用者的請求需要觸及 100 個後端服務時,某個後端的 P99 會直接變成使用者感知的中位數(median),服務層疊得越多,尾端延遲越容易被放大。

可以想像一個五層微服務架構的線上購物平台(catalog、pricing、inventory、reviews、recommendations),每個服務的平均延遲都在 80ms 以下,乍看非常健康。產品頁面平行呼叫這五個服務彙整資料,dashboard 上顯示的中位數延遲穩定在 120ms。但使用者的抱怨卻越來越多,反映頁面經常「卡頓」。工程師研究了好一陣子才發現,團隊只關注了 P50 與平均值,完全忽略了 P99。等他們把 P99 分布匯出後,才看清全貌:每個服務的 P99 落在 40–125ms 之間。在五個服務的平行呼叫中,整體端到端 P99 不是簡單相加(5×100ms≠500ms),而是「五個慢請求同時發生的機率」。粗估下來,約 2–3% 的使用者會同時遇到至少一個服務落在 P99 級別的延遲,導致頁面載入從平均的 120ms 膨脹到 800–1200ms。團隊一直在優化單一服務的平均值,卻沒看見 fanout 架構會把各層的尾端延遲「相乘」——這也是為什麼改善單一服務的平均值無法解決使用者的抱怨。

轉折點在於團隊開始把 P50 跟 P99 並排放在 dashboard 上看,而不是只留一條平均值的線。落差立刻現形:P50 依然穩定在 120ms 附近,P99 卻在 800-1200ms 之間跳動——多數請求沒事,少數請求的體驗完全走樣。問題也從「為什麼平均值已經很漂亮,使用者還在抱怨」變成「哪幾個服務在拖後腿」。從「看一條線」變成「看整個分布」,是排查尾端延遲最基本、也最容易被忽略的第一步,本文接下來會反覆用到這個切換。

為什麼「多數人沒事」不等於「系統沒事」

先把直覺講清楚,再進技術細節。一間餐廳,一百個客人裡九十五個吃得很滿意,老闆看報表滿意度 95%,覺得應該值得慶祝。但剩下的五個客人等了整整一小時才上菜——他們不會因為「隔壁桌很快」就覺得自己被善待,只會記得自己等了一小時。

線上服務也是同一種邏輯。平均值站在老闆的角度:整體資源花費多少、系統整體是否健康;percentile 站在客人的角度:我這一次,落在哪個等待區間。這兩個角度都對,但回答的是不同問題。監控系統如果只裝了老闆視角的儀表板,值班工程師會一直以為「一切都好」,直到客訴湧進來,才發現儀表板從來沒有問過「最慢的那一群人過得如何」——這就是為什麼互動服務的 latency 監控,從第一天就不該只有一條平均值的線。

① P95 不等於「保證 95%」

在一段明確時間窗內,把有效請求由快到慢排序,P95 是其中 95% 不超過的值。要寫清楚 window、request 類型與資料來源;把 background job、streaming response 和互動 API 混算,分位數一樣會說謊。

percentile 到底怎麼算出來的

把「排序取第 95% 位置」講白話一點:100 筆請求由快到慢排好隊,P95 大致是排在第 95 個人那裡的數字,代表這批樣本裡 95% 的請求都比它快(或一樣快)。但「大致」兩個字藏著陷阱——排名要落在哪一筆,業界至少有兩種常見算法:

nearest-rank:直接取排序後第 ceil(0.95 × N) 筆的值,簡單但在樣本數少時跳動明顯
linear interpolation:在第 95% 對應的位置附近,用相鄰兩筆做線性內插,數字更平滑

這聽起來像是統計課本裡的枝微末節,但在實務上,它直接決定了你能不能拿別人系統的 P95 跟自己的 P95 做有意義的比較。

用一組實際數字感受這個差異:假設 10 筆已排序的延遲樣本是 100, 105, 110, 120, 130, 150, 200, 400, 800, 2000(單位 ms)。

nearest-rank:
  ceil(0.95 × 10) = ceil(9.5) = 10 → 取第 10 筆 = 2000ms

linear interpolation(如 numpy 預設的 linear 方法):
  rank = 0.95 × (10 − 1) = 8.55
  介於第 9 筆(800ms)與第 10 筆(2000ms)之間
  P95 ≈ 800 + 0.55 × (2000 − 800) = 800 + 660 = 1460ms

同一份 10 筆資料,nearest-rank 給出 2000ms,linear interpolation 給出約 1460ms——差了超過 500ms。

樣本數越少,這種算法差異造成的落差越明顯;樣本數夠大時(例如上千筆),不同算法算出來的 P95 通常會非常接近。

同一份資料丟進 Grafana、Datadog、手寫的 numpy.percentile(),三個工具可能給出三個略有差異的數字,原因往往不是誰算錯了,而是內插方法不同。這不影響「看見尾端」這件事的方向,但如果要把某個百分位數字寫進 SLA 或拿去跟另一個系統比較,先確認雙方用的是同一種計算方式,否則兩個「P95」其實不是同一種東西。

更根本的一點是:percentile 是對「已經發生的樣本」做的描述性統計,不是對未來的保證。「這一小時的 P95 是 800ms」說的是過去這一小時裡已完成的請求裡 95% 落在 800ms 以下;它不承諾下一分鐘的請求也會如此——流量模式、下游健康度、甚至 GC 時機都可能在下一刻改變分布。把 P95 直接當成「95% 的使用者都會得到這個等級的服務」的承諾,是把描述性統計誤讀成了服務等級協議(SLA)。真正的 SLA 要另外定義觀測窗、更新頻率與違約條件,percentile 只是餵給那份 SLA 的原始材料之一。

平均值連流量本身都能騙

Google SRE Book 講監控哲學時舉過一個更極端的例子:一個系統奇數秒服務 200 req/sec、偶數秒完全服務 0 req/sec,平均下來是 100 req/sec,看起來像穩定的中等流量。真實情況是它每秒都在「全速」與「停擺」間切換,瞬間負載是平均值的兩倍,一半時間根本沒在做事——對容量規劃、佇列深度的意義,跟「穩定的 100 req/sec」完全不同。

同樣的邏輯套進 latency:一半請求 20ms、一半 2000ms,平均會落在 1000ms 附近,但沒有任何一筆請求真的落在 1000ms 附近,這個數字誰的體驗都不代表。平均值對極端值「不夠敏感又太敏感」——會被拉走,卻又不夠敏感到能被單獨看出來——這正是它在監控上最危險的地方。這也是為什麼 Prometheus 的 Histogram 型別存在:它記的是「每個桶裡各有多少筆」,把分布的形狀保留下來,而不是壓扁成一個數字(本文 ⑥ 段會動手做這件事)。

三個容易混淆的詞:mean、median、percentile

寫文章時常把這幾個詞隨口互換,但它們不是同一件事。

mean(平均值):把所有數字加起來除以個數,容易被極端值拉走。

median(中位數):把數字排序後取最中間那一個,其實就是 P50,不容易被極端值拉走。

percentile(百分位數):把「取中間」推廣成「取任意位置」,P50 是 median 的另一種說法,P95、P99 是往尾端移動的版本。

用一組數字具體感受差異:

樣本:10, 12, 11, 13, 10, 12, 11, 9, 10, 500

mean   = (10+12+11+13+10+12+11+9+10+500) / 10 = 59.8
median = 排序後第 5、6 名取平均 = (11+11) / 2 = 11
P95    = 排序後第 9.5 名附近 = 接近 13(依內插方法略有不同)

一個離群值(500)就能把 mean 從「大約 11」拉到「將近 60」。

median 完全不受影響,因為它只看排序後的位置,不看數值本身有多極端。

這也是為什麼很多監控實務會建議:quick health check 用 median 或 P50,觀察尾端才用 P95、P99——用不同的統計量分工,而不是全部塞進同一條線。

② 今日 DIY:故意製造 5% 慢請求

把以下概念加入你自己的測試 endpoint;不要在未知 production endpoint 加 sleep。

import time

def simulated_work(request_number: int) -> None:
    if request_number % 20 == 0:
        time.sleep(2)

送出至少 20 個受控請求,記錄每筆 latency,離線排序後比較 mean、P50、P95。若只跑 20 筆,percentile 粗糙是預期的;重點是看見同一份資料能同時有漂亮平均值和難看的尾端。

為什麼是「20 分之 1」而不是隨機機率,又為什麼是 5%

這裡故意用 request_number % 20 == 0 這種固定週期,而不是 random.random() < 0.05——長期比例一樣是 5%,但固定週期能重跑:你可以先預測「這次應該有 1 筆慢請求」,再驗證量測鏈路有沒有抓到它,抓到的數量不對就知道是量測出了問題,而不是運氣不好。這是故障注入的一個基本原則:先讓故障可預測,才能驗證偵測機制;隨機化留到你已經信任偵測機制之後,用來測試不可預期條件下系統會不會被誤導。

5% 這個數字本身也只是為了讓例子好算、好觀察,不是業界基準——多少比例的慢請求算異常,取決於你的 request class 與使用者容忍度(呼應 Day14 定義 SLO 時強調的「使用者能接受的等待是多久」)。今天的練習目的是讓你看得見 5% 慢請求對平均值和 P95 的影響幅度,警戒線該設在哪,得回到你自己的 SLO spec 去定義。

跑這個實驗時常見的三個坑

第一個坑是把「注入延遲的耗時」和「呼叫端量測的耗時」混在一起算。如果你是用 shell script 跑迴圈送 20 次 curl,記得量測目標是伺服器回應每一筆的 latency(例如從 response body 或伺服器端 log 讀出來),而不是整個迴圈跑完的總時間——後者混進了你自己 shell 迴圈的開銷、DNS 快取效應、以及 curl 進程啟動的成本,這些雜訊會讓「5% 慢請求」的訊號變得不清楚。

為了讓「量測目標」這句話更具體,實際去看要記錄哪一個時間點:如果用 shell script 呼叫 curl,curl 本身可以用 -w 參數印出伺服器實際回應的時間分解(例如 time_starttransfer),這比整個迴圈的 wall-clock 時間更接近伺服器端真正的處理耗時,值得先熟悉這個小技巧,再開始收集資料。

第二個坑是樣本數太少導致 P95 本身失去意義。20 筆資料的第 95 百分位,用最直觀的算法大概落在排序後第 19 筆附近——這代表你的「P95」實際上高度依賴那唯一一筆是不是剛好踩到慢請求。如果 20 筆裡剛好 0 筆是慢的,P95 會看起來完全正常;如果剛好 2 筆是慢的,P95 可能直接跳到 2 秒。這不是程式錯誤,而是小樣本 percentile 天生的抖動——這也是為什麼 ⑤ 段會特別強調「先確認你量到的是分布」,樣本數不夠時,percentile 本身就是一個不穩定的量測工具。

第三個坑是忘記把「注入延遲」跟「原本業務邏輯的耗時」分開看。如果你的測試 endpoint 本身還會做其他事情(例如呼叫真的 LLM API),慢請求的耗時會是「業務邏輯耗時 + 注入的 2 秒延遲」,而不是單純的 2 秒。這在解讀結果時容易誤判——你可能以為是注入延遲造成了尾端,但其實原本業務邏輯本身的變異就已經不小。乾淨的作法是先在一個什麼都不做、只回傳固定字串的 endpoint 上跑這個實驗,確認你能看懂注入延遲的訊號長什麼樣子,再套到有真實業務邏輯的 endpoint 上。

驗收

  • 資料集包含約 5% 人為慢請求。
  • 你能指出平均值與 P95 各回答什麼。
  • latency 標記 request 類型與時間窗。
  • 這個實驗由讀者自行執行;本文沒有結果可宣稱。

③ 業界怎麼處理尾端延遲

為什麼 fanout 架構會把尾端「相乘」

前言那個五層微服務的例子講了結論,這裡把算術補完整。假設每個後端獨立運作,各自的 P99 代表「這個服務有 1% 的請求落在慢速區間」。當一次使用者請求需要平行呼叫 n 個這樣的後端,只要其中任何一個踩到自己的 P99,使用者感受到的就是慢請求。用機率算「至少一個服務變慢」的機率:

P(至少一個服務落在自己的 P99 級距)
= 1 − P(全部 n 個服務都沒有落在 P99 級距)
= 1 − (0.99)^n

n = 5 時,1 − 0.99^5 ≈ 4.9%;n = 20 時,1 − 0.99^20 ≈ 18.2%;n = 100 時,1 − 0.99^100 ≈ 63.4%。

把這三個數字對照回開頭的五層微服務案例:n=5 算出來的 4.9%,跟文中「2-3% 使用者同時撞上至少一個服務 P99」的估算量級一致(真實情境裡各服務的 P99 機率不完全是獨立的 1%,也不會剛好都是 99%,實際比例會因相關性與各服務個別的可靠度而上下浮動,但整體量級吻合)。

這個對照的重點不是要讀者去驗算小數點後幾位是否精確,而是讓抽象的機率公式,跟前面讀過的具體案例對上號——公式不是憑空出現的,它就是在解釋為什麼那個案例會長成那個樣子。這正是《The Tail at Scale》裡那句話的算術依據:「當使用者的請求需要觸及 100 個後端服務時,某個後端的 P99 會直接變成使用者感知的中位數(median)」——不是誇飾,100 個後端時,幾乎三分之二的使用者請求都會撞到至少一個 P99 級的延遲。服務層疊得越多、平行呼叫的 fanout 越寬,這個機率爬升得越快,而且是非線性的。

用一張簡單的曲線感受這個非線性:

fanout 數量(n)     "至少一個服務踩到自己 P99" 的機率
  1                  1%
  5                  4.9%
 10                  9.6%
 20                 18.2%
 50                 39.5%
100                 63.4%

從 1 到 10,機率只成長了不到 10 個百分點;從 20 到 100,機率卻暴增超過 45 個百分點。

這條曲線一開始爬得慢、後段爬得極快,正是微服務架構越拆越細、依賴越串越多時,尾端延遲問題會突然「變得很嚴重」而不是「緩慢變差」的數學原因。這也是為什麼優化單一服務的「平均值」對使用者體驗幫助有限:使用者的體驗由「最慢的那個環節」決定,不是由「大部分環節的平均表現」決定。

hedged requests:把「等」變成「賽跑」

Google 論文「The Tail at Scale」(Dean & Barroso, 2013)提出的「hedged requests」技術,示範了工業級系統怎麼應對 tail latency:前面的請求還沒回應時(例如等到某個 percentile 的時間點,而不是一開始就重複發送),同時把同一請求送去另一個副本,取先到的那個回應、取消還沒回來的那個。把「賽跑」這個比喻再拆開一點:它不是同時鳴槍讓兩個選手從頭跑到尾,而是讓第一個選手先跑,只有在他跑得比預期慢的時候,才臨時加派第二個選手上場,誰先抵達終點就用誰的成績,還在跑的那個直接被叫停。

這個「先等、再補打」的設計,是整個技巧的關鍵,如果一開始就無條件雙打,成本會直接翻倍,而不是論文裡的 2%。

這個技術之所以有效,是因為尾端延遲的成因大多是 stragglers——不是後端整體變慢,而是少數請求撞上短暫的資源競爭、GC 暫停或網路波動,屬於偶發、局部、彼此獨立的事件。既然是獨立事件,同一份請求打到兩個副本,兩者「同時」踩到慢速的機率遠低於任一副本單獨踩到的機率,這也是為什麼只需要多打一小部分流量(實驗中約多 2% 的後端負荷),就能把 BigTable 查詢的 P99 從 1,800 ms 壓到 74 ms。尾端延遲不是「無可奈何只能接受的特性」,而是值得投資優化的工程問題;但它的前提假設是「重複發送是冪等、安全、不會產生副作用」,對會寫入資料或呼叫外部 API 扣款的 AI workflow step,直接照搬 hedged requests 之前要先確認這件事。

Discord 後來把歷史訊息儲存從 Cassandra(Java,P99 浮動在 40–125 ms)換成 ScyllaDB(C++,P99 穩定在 15 ms),發現垃圾回收暫停才是藏在底下的尾端延遲根源,全端都得盯著。

光看 dashboard 不夠:要主動把延遲打進系統裡

以上兩個案例都是「事後找到根因、事後優化」。Netflix 的做法更進一步:Chaos Engineering 工具家族裡的 Latency Monkey,專門在生產環境的 RESTful 通訊層為部分請求人為增加回應時間,然後觀察上游服務是否按預期反應——是否正確 timeout、正確切到 fallback,還是乾脆卡死無限等待。常態化跑過幾輪之後,Netflix 發現許多服務的 timeout 設定過寬或根本沒設,導致下游一個暫時的慢請求會被逐層放大成整條呼叫鏈的體感變慢。這呼應了本文 ② 段的 DIY 精神:平時 dashboard 沒有出現尾端延遲,不代表系統真的扛得住,得像 Netflix 一樣把「故意變慢」當成第一等公民的測試項目,而不只是等它自然發生才臨場應對。

hedged requests 不是免費的午餐

在往下講之前,要把 hedged requests 的成本講清楚,否則容易誤以為「多打一份請求」是萬靈丹。第一,資源成本:論文裡只多花約 2% 額外負荷,是因為觸發條件是「等到某個 percentile 才補打」,門檻設太低會讓額外負荷遠超過 2%,甚至自己造成下游過載——跟 ⑧ 段講的「retry 風暴」是同一種機制,差別只在 hedge 是預先設計、有節制的重複。第二,正確性成本:後端操作若不是冪等的(例如扣款、寫入訂單),同時打兩份就是在冒重複執行的風險,除非上層有 idempotency key 兜底。第三,複雜度成本:取消還沒回來的那份請求,在真實網路裡不是「立刻停止」,下游仍要承擔那份工作量,只是結果被丟棄。三個成本合起來說明:這不是隨手加的優化,而是要先確認操作是否唯讀、下游能不能撐住多出來的負荷、取消訊號是否真的省得下工作量。

對比表:三種業界處理尾端延遲的策略

把 ③ 段出現的幾個案例攤開來看,可以發現它們其實對應到尾端延遲問題的三個不同切入點:

策略 代表案例 解決的問題 代價
hedged requests(賽跑) Google BigTable 偶發、獨立的 stragglers 額外負荷、需要冪等性
換底層技術(消除根因) Discord:Cassandra → ScyllaDB 系統性、可重現的 GC pause 遷移成本、重寫存儲層
主動注入故障(驗證韌性) Netflix Latency Monkey 「不知道系統會怎麼反應」 需要在生產環境冒風險(但受控)

這三種策略不是互斥的,而是分屬不同階段:Netflix 的做法幫你「發現」問題在哪一層,Discord 的做法是「移除」已知的系統性根因,Google 的做法是在「根因暫時無法移除」時,用工程手段把偶發的慢請求變成使用者感受不到的慢請求。三者的共同點是,沒有一個依賴「盯著 dashboard 手動反應」——它們都是把應對尾端延遲的邏輯,寫進系統或流程裡,讓它在下一次事件發生時自動生效。

尾端延遲值得投資,不是只能忍受

很多團隊面對 P99 的態度是「這就是分布式系統的代價,沒辦法」,但前面三個案例剛好反駁了這個直覺:Google、Discord、Netflix 都沒有把「尾端延遲」當成運氣問題,而是當成可以拆解、可以量測、可以投入工程資源改善的具體目標。這對 AI workflow 尤其重要,因為 AI 系統的尾端延遲來源比傳統系統更多——不只有網路和硬碟,還有 model provider 排隊、token 生成速度、retrieval 冷快取、tool call 逾時。每一種來源都值得先量出來,再決定要不要投資改善,而不是一開始就放棄。

④ 先把問題問對:哪一段「慢」?

一句「這個 API 很慢」通常不夠拿來排障。

同一個 /ask 請求,可能在不同地方耗掉時間:

client click
  ↓
client → gateway network
  ↓
queue wait
  ↓
FastAPI validation
  ↓
retrieval / vector search
  ↓
prompt assembly
  ↓
model time to first token
  ↓
token generation / tool call
  ↓
parser and validator
  ↓
first byte or completed response

把所有時間只存成 latency_ms,就像只知道帳單變高、卻沒看消費明細。

先替每個指標寫出它回答的問題:

指標 回答的問題 常見誤讀
end-to-end latency 使用者從送出到拿到完整結果等多久? 以為它能指出根因
server latency 服務端收到請求後處理多久? 忽略 client、gateway 與 queue
queue delay 工作開始前等了多久? 把排隊誤當模型變慢
retrieval latency 文件搜尋花多久? 以為 retrieval 成功就代表答案正確
TTFT 串流回覆第一個 token 多快出現? 把第一個 token 當完成答案
generation latency 產生剩餘輸出花多久? 忽略輸出長度改變
tool latency 外部工具是否拖慢 workflow? 忽略 tool 是否真的產生價值

為什麼「使用率越高,尾端越先爆」

排隊理論有一個直覺但常被低估的結論:系統使用率越接近滿載,等待時間不是線性增加,而是急遽拉長。

用一個簡化的比喻理解:一條有一個收銀台的隊伍,平均每分鐘進來的顧客數如果遠低於收銀台每分鐘能服務的人數,隊伍幾乎不會排長,等待時間穩定而短。但當進來的顧客數量接近收銀台的服務能力上限時,只要有那麼幾個顧客結帳比較久,後面所有人的等待時間都會被連鎖拖長,而且拖長的幅度會越來越誇張,不是穩定增加一點點。

使用率 50%   →  平均等待時間:短,且穩定
使用率 80%   →  平均等待時間:明顯變長,尾端開始拉開
使用率 95%   →  平均等待時間:任何一點波動都會讓隊伍瞬間爆炸性變長
使用率 99%   →  幾乎必然有人要等到天荒地老

這個現象解釋了為什麼很多系統在「日常流量」下 P95、P99 表現得體面,一旦遇到流量尖峰(例如促銷活動、新聞事件帶來的使用者暴增),P99 不是溫和上升,而是斷崖式惡化。

它也解釋了 ⑦ 段那個假想值班場景裡「worker saturation」被放在 dashboard 最後一列的原因:使用率是一個領先指標,在它逼近臨界值之前先看到,比等到 P99 已經爆炸才去查資源使用率更有意義。

對 AI workflow 而言,這個臨界值往往不是 CPU 或記憶體,而是 GPU 佇列深度、或 provider 端的併發配額——這些資源一旦逼近上限,尾端延遲惡化的速度會比傳統 Web 服務更快,因為每個請求佔用資源的時間(token 生成通常是秒級)遠比傳統 API 的毫秒級請求長得多,佇列效應被放大。

一個十步驟的假想 trace 時間軸

把 ④ 段開頭那張「client click 到 completed response」的架構圖,換成一次具體請求的時間戳,會更容易理解每一段各自在耗掉多少時間。以下是一個依本文架構假想出來的範例,不是真實系統擷取的 trace。

t = 0ms      client click,使用者按下送出
t = 15ms     請求離開瀏覽器,開始網路傳輸
t = 40ms     gateway 收到請求
t = 45ms     FastAPI validation 完成,進入 workflow
t = 46ms     排進 worker queue(此刻 queue 尚無積壓)
t = 48ms     worker 取出這筆請求,queue delay 只有 2ms
t = 90ms     retrieval / vector search 完成
t = 130ms    prompt assembly 完成,送出給模型
t = 310ms    model TTFT,第一個 token 抵達
t = 1,050ms  token generation 結束
t = 1,060ms  parser 與 validator 通過
t = 1,065ms  response 送回 client,client 端算完成

這一筆請求的 end-to-end latency 是 1,065ms,看起來相當健康。

拆開來看,占最大宗的是 model 的 token generation(約 740ms),其次是 TTFT 本身(180ms,從 130ms 到 310ms)。

retrieval 和 queue 幾乎可以忽略不計。

如果只存一個 latency_ms = 1065,你完全看不出這個結構;下次如果同一個請求變慢到 3,000ms,你不會知道是 retrieval 突然變慢、還是 provider 排隊變長、還是模型輸出變得更長。

有了這張拆解時間軸打底,才看得懂為什麼下面要把 TTFT 單獨拉出來講。

TTFT、完成時間與「看起來有反應」

串流 LLM 服務尤其要拆開看。

使用者看到第一個 token,會覺得系統開始回應;這是 TTFT 的價值。

但若第一個 token 在 400 ms 出現,完整答案在 45 秒後才結束,任務仍可能失敗。

TTFT 快
不代表
End-to-end 快
不代表
Task success

反過來,短答案的 end-to-end latency 很漂亮,也不一定代表模型快。

它可能只是少產生了 500 個 token。

因此比較 prompt 或 model 版本時,至少要同時保留輸入 token、輸出 token、是否使用 tool、回覆類型與 workflow status。

否則「新版快了」有時只是新版回答得更短,甚至漏答了。

使用者的耐心不是線性的

TTFT 之所以值得單獨量測,背後有一個心理學上的原因:使用者對「系統有沒有在回應」的判斷,主要來自第一次收到反饋的時間點,而這個判斷不是均勻分布的,而是分成幾個明顯的門檻。大致上:在 100ms 內拿到反饋,感覺像是「瞬間」發生;100ms 到 1 秒之間,使用者會察覺到延遲,但還在「這是系統正常運作的一部分」的範圍內;超過 1 秒,使用者開始意識到自己在等待,注意力從任務轉移到「等待」這件事本身;超過 10 秒,相當比例的使用者會直接放棄,切分頁、關視窗,或是覺得系統壞了。

這組門檻解釋了一個乍看反直覺的現象:一個總耗時 4 秒、但 TTFT 只要 200ms 的串流回應,使用者體感通常會好過一個總耗時 2 秒、但 TTFT 要 1.5 秒的非串流回應——即使後者的「總時間」數字明明比較小。原因是使用者在 200ms 就已經開始讀到文字,注意力被逐步餵入的內容佔住,體感上是「系統一直在動」;而 TTFT 1.5 秒的那個版本,使用者前 1.5 秒面對的是一片空白,這段空白會被感知放大,即使最終總時間比較短。這也是為什麼許多 LLM 應用把串流輸出當成預設選項,不是為了炫技,而是把「使用者開始感知回應」的時間點,從 end-to-end latency 拉到 TTFT——用一個通常小一個數量級的數字,去對應使用者真正在意的體驗。

但這個技巧有前提:TTFT 快只解決「使用者是否覺得系統活著」,不解決「使用者是否拿到正確或完整的答案」。這正是本文 ③ 那句話要強調的順序——TTFT 快 不代表 End-to-end 快 不代表 Task success——把這條鏈接完整:一個系統可以在體感上「感覺很快」,同時在事實上「沒有把事情做完」,這兩件事分別要用 TTFT 和 task-level evaluation(Day 22 之後的主題)來量,不能用同一個數字回答兩個問題。

GPU 批次處理:一個 AI 系統特有的尾端來源

傳統 API 的尾端延遲,成因通常是網路抖動、GC pause、資源競爭,這些在③段已經講過。

AI 推論還多了一個傳統系統少見的成因:GPU 的批次處理(batching)。

為了把昂貴的 GPU 用滿,多數 model serving 框架不會「一個請求進來就立刻算」,而是先等一小段時間,把幾個請求湊成一批,一起送進 GPU 運算。

request A 抵達 t=0ms
request B 抵達 t=8ms
request C 抵達 t=15ms
  ↓ batching window 通常設在 10-50ms 之間
批次送入 GPU(t≈20ms),三個請求「同時」開始運算

這個機制對平均吞吐量是好事:GPU 一次處理一批,單位時間能服務的請求數遠高於一個一個算。

但對個別請求的延遲,它是一個隱藏的變因:同樣的請求,如果不巧抵達在一個批次「剛好送出」之後,得等到下一個 batching window 才會被撿起來,平白多等了幾十毫秒。

流量越高,batching window 通常會動態調整(更多請求時縮短等待,湊批更快),但如果流量突然尖峰、GPU 本身已經在滿載運算前一批,新請求得排隊等 GPU 空出來,这就是 TTFT 從 100ms 級距跳到秒級的另一個常見原因——不是網路慢,也不是 provider 掛了,是你的請求剛好卡在別人的批次後面。

這也是為什麼同一個 provider、同一個 model,尖峰時段的 TTFT P95 經常比離峰時段高出好幾倍:不是模型變笨了,是佇列和批次調度在尖峰期間承受了更大壓力。

TTFT 本身也有自己的變因,不是模型速度的直接映射。它受 prompt 長度(更長的 prompt 需要更久的初始 forward pass)、模型大小、批次處理策略(provider 端把多個請求湊在一起跑,你的請求要先排進某一批)、以及優先權佇列(付費層級或流量控制策略)影響。這代表在流量正常的情況下 TTFT 可能穩定落在 100–300ms,一旦進入流量尖峰,provider 端的排隊時間拉長,TTFT 可能暴增到 3–5 秒——服務本身沒有故障,只是「輪到你」的等待變長了。這也是為什麼 ⑧ 段會提到「context 越大不等於答案越可靠」:更長的 prompt 除了增加 token 成本,也會直接拉長 TTFT,是一個同時影響體驗與成本的槓桿,值得在設計 retrieval 策略時一併考慮。

一個可被檢查的 latency contract

先不要急著宣布 production SLO。

在 Lab 中,可以把互動式政策問答暫時寫成下列假設:

request class: interactive_policy_qa
valid request: schema valid, authenticated, not cancelled before service receives it
completion: response contract passes parser and validator
latency boundary: server-side end-to-end duration
target: P95 <= 3 seconds in a 1-hour Lab window
exclusions: offline batch jobs; client-side network time; cancelled-before-start requests

3 seconds 是練習用假設,不是對使用者的承諾——它只是落在④段那張 TTFT 心理門檻裡「使用者開始主動等待」與「使用者可能放棄」之間的一個保守值,方便在 Lab 裡練習寫 spec、設 bucket、畫 dashboard,不是任何產業標準。它的用處是迫使你講清楚:量的是誰、從哪一刻開始、在哪一刻結束,哪些流量不該混進去——這個逼問的過程,往往比拿到的文件本身更有價值。逼自己回答「哪些流量該被排除」,常常會發現團隊裡不同的人對「一次成功的請求」根本有不同的定義(使用者中途取消算不算失敗?),這種分歧如果不趁寫 contract 時攤開討論,會一路潛伏到某次事故覆盤時才爆出來。如果未來產品改成長篇報告生成,這個目標很可能不再合理;變更時要改 spec、測試、dashboard 說明和 alert,而不是只把 Grafana 門檻調大。

把這份 contract 的每一行對照它想避免的具體錯誤:request class 避免把 /health、/ask、/admin/report 混進同一條 P95(⑤段的母體污染問題);valid request 避免把根本沒送達伺服器的請求也算進分母;completion 避免誤把 server 端「完成」當成使用者「拿到」;latency boundary 明確選了 server-side end-to-end,client 端網路時間不在責任範圍內;exclusions 避免 batch job 或已取消的請求悄悄拉低(或拉高)達標率。每一行都是一個曾經真實發生過的混淆,被提前寫成規則——這也是為什麼 Day14 說 SLO spec 是「先把問題問清楚」的工具,不是拿來填表格的形式主義。

⑤ percentile 的小算術:先確認你量到的是分布

假設我們有 20 筆有效請求。

其中 19 筆是 100 ms,1 筆刻意睡 2 秒。

19 × 100 ms = 1,900 ms
 1 × 2,000 ms = 2,000 ms
total        = 3,900 ms
mean         = 195 ms

195 ms 看起來很健康。

可是那一位慢請求的使用者,等的是 2 秒,不是 195 ms。

把慢請求增加到 5 筆,平均會提高;但真正的危險在於 tail 的形狀開始變厚。

15 × 100 ms =  1,500 ms
 5 × 2,000 ms = 10,000 ms
total         = 11,500 ms
mean          =    575 ms

平均值終於變醜了,可是它仍沒有告訴你是「每個人都等 575 ms」,還是「四分之一的人等 2 秒」。

這兩種使用者體驗、容量問題與修復方式都不一樣。

再往下拆:慢請求也有自己的分布

上面兩組算術都讓慢請求固定是 2 秒,方便手算;真實世界裡「慢請求」本身通常也有自己的分布。假設 20 筆請求裡,15 筆落在 100ms,3 筆落在 1,500ms,2 筆落在 8,000ms:

15 × 100 ms  =  1,500 ms
 3 × 1,500 ms =  4,500 ms
 2 × 8,000 ms = 16,000 ms
total         = 22,000 ms
mean          =  1,100 ms

mean 更醜了,但真正說故事的是三群人分別在等什麼:多數人等 100ms、少數人等 1.5 秒、極少數人等 8 秒。這種多層次分布在真實系統裡很常見——一層正常流量、一層踩到快取失效或輕微排隊、一層踩到真正的資源耗盡或下游逾時。只用 P95 一個數字,可能剛好切在兩層之間,看不出這是兩種不同成因、需要不同修法的問題,這也是為什麼成熟的監控實務除了 P50/P95/P99,通常還會保留完整的 histogram bucket 分布圖。

P50、P95、P99 各自是不同鏡頭

把 mean、P50、P95、P99、max 想成五支不同焦段的鏡頭,沒有哪一支是「正確答案」。廣角鏡頭(mean)適合看整體構圖,看不清楚邊緣細節;長焦鏡頭(P99、max)適合看邊緣細節,看不到整體構圖;中焦鏡頭(P50)適合看典型使用者的日常體驗。值班時該用哪支鏡頭,取決於你這次想回答的問題:「這次改版對整體資源成本有沒有幫助」該用廣角鏡頭,「有沒有使用者正在受苦」該換上長焦鏡頭。max 這支鏡頭最極端,只拍下整個時間窗裡唯一一筆最慢的樣本,適合抓「是不是存在一個完全失控的離群值」,不適合描述任何族群的日常體驗。一個健康的 dashboard,通常同時擺著這五支鏡頭的畫面,而不是替每個人選定唯一一支「正確」的鏡頭。

指標 適合觀察 不適合單獨回答
mean 總體資源時間、粗略趨勢 最慢使用者的體驗
P50 典型請求的體驗 少數嚴重卡住的請求
P95 顯著慢的使用者是否變多 最極端的尾端
P99 長尾、級聯與偶發停頓 低流量下的穩定結論
max 是否存在離群個案 常態使用者體驗

P99 不是越高階越好——若一個小 Lab 一小時只有 30 筆請求,P99 幾乎等於在凝視一筆資料,此時保留原始測試樣本、顯示 event count,往往比在 dashboard 畫一條看似精準的 P99 線更誠實。

一個常被忽略的陷阱:percentile 不能簡單地「跨實例平均」

假設你的服務有 10 台實例,各自算出自己的 P95,接著把 10 個 P95 數字加起來除以 10,得到一個「平均的 P95」,這個數字是錯的,而且錯得很隱蔽。percentile 是一個非線性統計量,不像總數或平均值那樣可以直接跨群體合併。想像其中 9 台實例的 P95 是 100ms(流量健康),1 台實例因為某個 pod 卡在資源競爭裡,P95 飆到 5000ms;如果 10 台的流量並不平均(例如那台出問題的實例因為某種路由原因只吃到很少流量),跨實例平均算出來的「P95 的平均值」可能只有 590ms 左右,看起來只是小幅度上升,完全掩蓋了「有一台實例完全在報廢」的事實。

正確的做法是把所有實例的原始 latency 樣本(或它們回報給 Prometheus 的 histogram bucket 計數)先合併,再對合併後的整體分布重新計算一次 P95——這正是 ⑥ 段 PromQL 範例裡 sum by (le) (...) 這一步在做的事:先把各實例、各時間點的 bucket 計數加總,讓 histogram_quantile() 是對整個母體的分布做估算,而不是對「已經算好的 percentile 們」再取一次平均。這也是為什麼「先確定你的 event 母體是什麼」(本節開頭那句話)如此重要:母體如果選錯,不是數字會有一點誤差,而是整張圖問的問題已經悄悄變了。

換一個角度看:P99 在講「什麼」

有個常見的直覺誤解,覺得 P99 是在講「最慢的 1% 使用者」,但更精確的說法是「1% 的(有效)請求」。同一個使用者如果在同一段時間內發出多次請求,其中一次恰好落在慢速區間,這個使用者的體驗會被算進 P99,但他在其他請求裡的體驗完全正常,並不代表他是「系統裡最不幸的那 1% 的人」。反過來,一個高頻使用的重度使用者,只要發出的請求數量夠多,落入 P99 級距的機率也會累積得比輕度使用者高——即使系統本身完全公平,也可能讓重度使用者「主觀上感覺自己比較常遇到卡頓」。這不是統計錯誤,而是提醒你:percentile 描述的是請求層級的分布,不是使用者層級的體驗分布;如果你真正關心的是「有多少比例的使用者,至少遇過一次慢請求」,那是另一個要單獨定義、單獨計算的指標,不能直接從 request-level 的 P99 推論出來。

percentile 需要一致的母體

每一種混法拆開來看都不是刻意犯的錯,而是「先求快,之後再拆」的順手之作,只是那個「之後」經常沒有真的發生。下列混法很常見,也很容易讓圖表變成謎語:

把 /health、/ask、/admin/report 放進同一個 histogram
把成功、timeout、client cancellation 放進同一個 latency 分布
把 streaming 的 first byte 和 non-streaming 的 complete response 當同一個結束點
把 batch job 與互動式 request 混在同一條 P95

比較前先固定一件事:這張圖的「一筆 event」是什麼?對本系列的 /ask,第一版可以選擇「server 完整產出符合 response contract 的結果,或明確結束失敗」作為 event。若要量 TTFT,請建立另一個明確命名的 metric,不要把兩個時間點塞進同一個 duration_seconds。

命名本身也是一種文件:ask_request_duration_seconds 已經在告訴任何看到它的人,這是 /ask 這條路由的耗時分布。如果之後要新增 TTFT,取一個一樣明確的名字,例如 ask_ttft_seconds,而不是硬塞進同一個 histogram、多加一個 label 去區分——那會讓同一個 histogram 混著兩種不同量級的數字,任何查詢都得先小心篩選正確的 label。一個 metric、一個明確的意義,是避免⑤段「母體污染」最簡單也最容易被忽略的方法。

⑥ 今日實作:在 FastAPI 記錄 histogram,而不是只印平均值

以下是給讀者自行放進 Day16 DIY 專案的教材。

本文沒有建立 Day16/DIY、沒有安裝套件、沒有啟動 server,也沒有執行任何測試。

請依自己的 Day2 Lab 套件版本和專案結構調整 import。

這段實作到底在驗證前面哪句話

在動手之前,先講清楚接下來六個步驟合起來要驗證的具體論點是什麼,這樣即使讀者不打算真的跑一次,也能看懂這段程式碼的意義,而不是把它當成又一份「範例程式碼」略過。

這六個步驟合起來,要驗證的是①段那句「平均值能掩蓋少數非常慢的請求」在真實 FastAPI 服務裡是不是成立、以及⑤段那句「percentile 需要一致的母體」在實際操作上要怎麼落地。具體來說:

步驟 1(設計 label)  驗證:⑥段前言「Prometheus 適合聚合,不適合存個別識別資訊」
步驟 2(計時邊界)    驗證:④段「一句『這個 API 很慢』通常不夠拿來排障」——先框出 server 這段
步驟 3(延遲開關)    驗證:②段「故意製造 5% 慢請求」——在真實 server 而非離線腳本重現
步驟 4(送出流量)    驗證:⑤段「先確認你量到的是分布」——用固定比例而非隨機流量
步驟 5(PromQL 算 P95)驗證:①段「平均值和 P95 是不同的問題」——同一份資料算出兩種數字
步驟 6(trace/log 追根因)驗證:④段「哪一段慢」——從一張 P95 圖鑽回具體 workflow step

如果讀者只想抓重點而不逐行跑,讀完這張對照表,再挑一兩個步驟看程式碼細節,也能掌握這段實作想證明什麼。

步驟 1:先設計有限的 metric labels

Prometheus 很適合拿來聚合 request 類型、workflow 狀態與固定路由。

它不適合存每一個 request 的識別資訊。

這個分工可以用一句話記住:Prometheus 回答「多少」和「多快」的聚合問題,trace 和 log 回答「這一筆」的具體問題。

兩者不是互相競爭的工具,而是分別站在放大鏡的兩端:Prometheus 讓你看見趨勢與異常的存在,trace 和 log 讓你鑽進去看清楚異常長什麼樣子。

把這兩端搞混,常見的失敗模式有兩種:一種是把所有東西塞進 Prometheus label,妄想單靠一個系統回答所有問題,結果撞上⑥段前面提過的高基數問題;另一種是完全不用 histogram,只靠 log 逐筆分析,每次要看趨勢都得寫一段查詢腳本掃過所有 log,既慢又容易漏掉沒被特別記錄下來的異常。

from prometheus_client import Histogram


ask_request_duration_seconds = Histogram(
    "ask_request_duration_seconds",
    "Server-side end-to-end duration for /ask requests",
    labelnames=("route", "workflow_status", "model_route"),
    buckets=(0.1, 0.25, 0.5, 1, 2, 3, 5, 10, 30),
)

這裡的 bucket 要服務於你想問的問題。

如果 Lab latency target 是 P95 小於 3 秒,2、3、5 秒附近就值得有 bucket;若只放 1 和 10,你會知道它在兩者之間,卻不知道是否剛好違反 3 秒目標。

label 值必須是低基數且可枚舉。

可以:route=/ask、workflow_status=completed、model_route=primary
不可以:request_id=req_9f…、user_id=…、prompt=完整問題、exception=完整錯誤

request_id、trace_id、prompt 版本與具體例外內容,應放在 structured log 或 trace,讓你從 P95 尖峰再往下鑽。

為什麼「低基數」不是隨口說說,而是有具體數學後果

label 和 metric 名稱合起來,決定未來能不能查、怎麼查、查起來會不會拖垮監控系統——設計得好,六個月後接手的人也能一眼看懂;設計得不好,只剩一堆猜不透的數字。

Prometheus 內部,每一組不同的 label 值組合都會被存成一條獨立的時序資料(time series)。route、workflow_status、model_route 三個 label 若分別有 3、4、2 種可能值,總共只有 3 × 4 × 2 = 24 條——完全可控。但如果不小心把 request_id 塞進 label,每來一筆新請求就多生出一條全新的 series,一天十萬筆請求就多出十萬條,而且幾乎永遠不會再被更新。Prometheus 得為每一條 series 保留記憶體 metadata,這種「high cardinality」問題是實務上真實會拖垮記憶體、拖慢查詢速度的常見原因,而且往往不是立刻爆炸,而是隨時間累積才被發現。這也是為什麼步驟 1 一開始就要求「先設計有限的 metric labels」——不是風格偏好,是替一個會隨規模惡化的問題提前把關。

步驟 2:將計時邊界寫在程式碼旁邊

以下範例刻意不使用 decorator 魔法。

顯式的 perf_counter() 能讓讀者看到開始與停止位置;如果你的 contract 不同,先改邊界,再談指標。

from time import perf_counter

from fastapi import FastAPI, HTTPException

from app.metrics import ask_request_duration_seconds
from app.workflow import run_policy_workflow


app = FastAPI()


@app.post("/ask")
async def ask(question: str) -> dict:
    started_at = perf_counter()
    workflow_status = "failed"
    model_route = "primary"

    try:
        result = await run_policy_workflow(question)
        workflow_status = result.workflow_status
        model_route = result.model_route

        if workflow_status != "completed":
            raise HTTPException(status_code=503, detail="workflow not completed")

        return {"answer": result.answer, "sources": result.sources}
    finally:
        elapsed_seconds = perf_counter() - started_at
        ask_request_duration_seconds.labels(
            route="/ask",
            workflow_status=workflow_status,
            model_route=model_route,
        ).observe(elapsed_seconds)

這一版是「server 收到 route handler 控制權,到 handler 離開」的時間,不包含瀏覽器排隊、DNS、TLS、反向 proxy 前的網路時間,也不保證 client 收到最後一個 byte——這不是缺點,是指標的範圍,寫出範圍之後才能在另一層補 client journey telemetry,不會誤以為兩者是同一件事。把這個範圍對照④段那張十段式時間軸圖:

client click
  ↓  ← 這一段:client journey telemetry(本文沒有實作,是未來的補充項)
client → gateway network
  ↓
queue wait
  ↓  ┐
FastAPI validation      │
  ↓                     │
retrieval / vector search│  ← 這整段:ask_request_duration_seconds 涵蓋的範圍
  ↓                     │     (從 handler 拿到控制權,到 handler return)
prompt assembly          │
  ↓                     │
model TTFT               │
  ↓                     │
token generation          │
  ↓                     │
parser and validator     │
  ↓                     ┘
first byte or completed response

這個範圍的選擇不是隨意的,而是刻意對齊「應用程式能控制、能負責的部分」——client 端的網路狀況、瀏覽器渲染速度,不在應用程式的控制範圍內,把它們混進同一個 histogram,只會讓這個指標同時回答兩個團隊(前端與後端)各自負責的不同問題,失去它原本該有的診斷力。

步驟 3:為慢請求注入一個可重現開關

不要根據隨機數在 Lab 裡製造故障。

隨機會讓 demo 刺激,卻讓失敗難以重跑。

可以傳入一個只限本地測試的延遲設定:

import asyncio


async def inject_delay_if_requested(delay_ms: int | None) -> None:
    if delay_ms is None:
        return
    if not 0 <= delay_ms <= 10_000:
        raise ValueError("delay_ms must be between 0 and 10000 in this Lab")
    await asyncio.sleep(delay_ms / 1_000)

在自己的測試 route 或 workflow fixture 呼叫它:

@app.post("/ask")
async def ask(question: str, test_delay_ms: int | None = None) -> dict:
    started_at = perf_counter()
    workflow_status = "failed"
    model_route = "primary"

    try:
        await inject_delay_if_requested(test_delay_ms)
        result = await run_policy_workflow(question)
        workflow_status = result.workflow_status
        model_route = result.model_route
        return {"answer": result.answer, "sources": result.sources}
    finally:
        ask_request_duration_seconds.labels(
            route="/ask",
            workflow_status=workflow_status,
            model_route=model_route,
        ).observe(perf_counter() - started_at)

這個 query parameter 只能存在於受控的本地 Lab 或明確隔離的測試入口——若把「任意 sleep」帶進 production public API,就不是 chaos experiment,而是發給使用者一張免費的等待券。這個開關特意設計成「參數化、有上限、有明確錯誤訊息」,而不是隨口寫一個 if random.random() < 0.05: time.sleep(2) 藏在程式碼深處:後者一旦忘記移除,會變成日後排查效能問題時沒人會想到要懷疑的地雷;test_delay_ms 這個參數名稱本身就是自我說明,0 <= delay_ms <= 10_000 的邊界檢查則防止有人不小心傳入天文數字,把測試請求變成永遠不會回應的掛起連線。

步驟 4:送出固定比例的受控 traffic

啟動方式依你的 DIY README 為準。

若 Day16 DIY 沿用 uvicorn,可從 Day16/DIY 使用類似指令:

uv run uvicorn app.main:app --reload

接著在另一個 terminal 對本機 endpoint 發 20 次請求。

前 19 次不帶延遲,最後 1 次帶 2,000 ms;請依自己的 request schema 補上 body 或認證。

curl -sS -X POST 'http://127.0.0.1:8000/ask?question=remote-work-policy'
curl -sS -X POST 'http://127.0.0.1:8000/ask?question=remote-work-policy&test_delay_ms=2000'

重複指令時,不要把 shell loop 的執行時間直接當服務 latency。

真正要看的資料是 histogram observation 或 API 所寫出的結構化 log。

請紀錄測試時間、請求數、慢請求數、delay 值與是否有其他背景程序。

這些小細節會決定下一次你看到不同數字時,能否判斷是程式變了還是測試條件變了。

步驟 4.5:確認 Prometheus 真的在 scrape 這個 endpoint

在查 PromQL 之前,先確認資料真的進得去 Prometheus。

沿用 Day2 Lab 的 prometheus.yml,幫這個服務加一個 job:

scrape_configs:
  - job_name: "sre-lab-api"
    scrape_interval: 15s
    static_configs:
      - targets: ["api:8000"]
    metrics_path: /metrics

scrape_interval 設 15 秒,代表 Prometheus 每 15 秒才抓一次資料。

這個數字要記在腦中:如果你剛送出測試流量就立刻去查圖,很可能資料點還沒被抓進來。

確認 target 是否成功,可以先看 Prometheus 自己的 /targets 頁面:

curl -s http://localhost:9090/api/v1/targets | grep -A 3 "sre-lab-api"

如果 health 欄位不是 up,往下的所有 PromQL 查詢都只會是空的圖,不代表你的程式碼有問題。

步驟 5:用 PromQL 看 P95,但同時看樣本數

若 histogram 已被 Prometheus scrape,下面 query 可算 /ask 的 P95:

histogram_quantile(
  0.95,
  sum by (le) (
    rate(ask_request_duration_seconds_bucket{route="/ask"}[5m])
  )
)

若想分開看 workflow outcome:

histogram_quantile(
  0.95,
  sum by (le, workflow_status) (
    rate(ask_request_duration_seconds_bucket{route="/ask"}[5m])
  )
)

搭配 request rate 或 count:

sum(rate(ask_request_duration_seconds_count{route="/ask"}[5m]))

histogram_quantile() 是根據 bucket 估算分位數,不是保存每一筆原始延遲後再精確排序,這也是 bucket 邊界要先設計的原因。Lab 的 20 筆樣本可能因 scrape timing、rate window 或桶內插值而呈現和手算不同的數字,這是量測模型的限制——常見的錯誤反應是手算 P95 是 118ms、PromQL 查出來 95ms,於是不斷調 bucket 邊界或 rate window 直到兩個數字對上,但你不是在讓量測更準確,而是在「校準」一個本來就不該完全一致的東西:手算是精確排序,PromQL 是桶估算,落差是方法論本身決定的。正確的反應是先確認落差量級是否合理(例如落在相鄰兩個 bucket 邊界之間),合理就接受它,把心力留給真正該調整的地方:bucket 邊界該不該涵蓋你關心的門檻值(例如 SLO 定的 3 秒)。

步驟 6:用 log 或 trace 找出尖峰的原因

一張 P95 圖可以告訴你何時變慢,不能告訴你哪個 workflow step 變慢。

每個請求完成時,可寫一筆固定結構的事件:

{
  "event": "ask.completed",
  "request_id": "req_local_017",
  "trace_id": "trace_local_017",
  "workflow_status": "completed",
  "model_route": "primary",
  "retrieval_ms": 42,
  "ttft_ms": 180,
  "generation_ms": 820,
  "tool_ms": 0,
  "end_to_end_ms": 1046,
  "prompt_version": "policy-qa-v1"
}

範例中的 ID 是示意值。

真正的 request ID 不可拿來當 Prometheus label;它的工作是讓你從 dashboard 的時間範圍進到 Loki 或 trace backend,定位那幾筆 tail request。

若 end_to_end_ms 升高而 retrieval_ms 穩定、ttft_ms 卻升高,優先查 provider queue、model route 或 prompt size。

若 ttft_ms 穩定、generation_ms 上升,則看輸出 token、tool loop 或串流中斷重試。

這些都是調查假設,不是自動結論;要用 trace 和同時間的 dependency telemetry 驗證。

為什麼這筆 log 要把時間拆成六個欄位,而不是一個總數

把 retrieval_ms、ttft_ms、generation_ms、tool_ms、end_to_end_ms 分別記錄,而不是只記一個 latency_ms,成本是多存幾個欄位,換來的價值遠不只是「看起來比較詳細」。

它讓你能在不重新部署、不重新跑一次請求的前提下,直接用既有資料回答「這次變慢,卡在哪一段」——這是 diagnosability(可診斷性)和單純的 observability(可觀測性)之間的差異:後者告訴你發生了什麼,前者讓你不必再多問一輪就能定位原因。

它也讓你能做時間序列上的相關性分析:例如畫出 retrieval_ms 和 end_to_end_ms 隨時間的曲線,如果兩條線幾乎同步起伏,說明 retrieval 是主導端到端延遲的主因;如果 retrieval_ms 平穩、只有 end_to_end_ms 在跳,說明問題出在別的階段。

這種相關性分析,只有在你把每一段都存成獨立欄位時才做得到;只存一個總數,你永遠只能停在「變慢了」這個層次,無法再往下問一句「為什麼」。

prompt_version 這個欄位看似跟延遲無關,放在這裡是為了避免④段和⑨段都提過的陷阱:一旦某次 latency 異常剛好對應到 prompt 或 model 版本的變更,你需要立刻知道,而不是事後翻部署紀錄慢慢比對。

真的跑起來時,預期會看到什麼、會踩到什麼坑

如果讀者真的把這段程式碼放進自己的 Day16/DIY 專案動手跑,這裡列出幾個大概率會遇到、容易誤判成「我哪裡寫錯了」的現象。

現象一:前 19 次請求的 latency 不是整齊的一個數字。 即使沒有刻意加 sleep,FastAPI 第一次請求通常比後續慢一些——直譯器 import 快取、連線池建立、記憶體頁面載入的成本,都會讓「第一筆請求」自然比「第十筆」慢。這是真實系統裡幾乎必然存在的「冷啟動」效應,呼應⑤段「小樣本 percentile 天生抖動」。

現象二:histogram_quantile() 算出的 P95,跟手算排序的 P95 不會完全一樣。 這是「bucket 估算」而非「精確排序」的必然結果。判斷方式:先確認 bucket 邊界(buckets=(0.1, 0.25, 0.5, 1, 2, 3, 5, 10, 30))有沒有涵蓋你注入延遲的區間(本文注入 2 秒,剛好卡在 1 和 2 之間),落在兩個 bucket 之間就會有內插誤差——不是誰算錯,是兩種方法本來就在回答「大約」而非「精確」。

現象三:本地起 Prometheus scrape 後立刻查 Grafana,經常看不到最新的慢請求。 這是 scrape interval 與 rate(...[5m]) 時間窗的組合效應:資料點可能還沒被抓到,抓到了也會被過去 5 分鐘的資料平滑掉。監控系統呈現的「現在」,通常落後真實的「現在」幾十秒到幾分鐘,值班時不能拿著剛部署完的畫面就斷定「沒問題」。

現象四:如果測試 endpoint 前面有 nginx、gateway 等中介層,本機量到的 latency 會系統性地比使用者實際體驗到的短。 這呼應④段那張時間軸圖——client → gateway network 這段不在 server-side latency 裡;別把本地數字直接拿去回答「使用者實際等多久」。

下集預告

percentile 算清楚、histogram 也埋好了,接下來要處理「值班的人怎麼在半夜三點看懂這張圖」:dashboard 該放什麼、AI workflow 的尾端延遲怎麼在多次呼叫裡被「相乘」放大,以及那幾個最容易把漂亮平均值誤判成系統健康的陷阱。下篇(Day 16 下)接著講。


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 15(下)|Availability:不是把 200 數一數
下一篇
Day 16(下)|平均 Latency 為什麼會騙人?請看尾端
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言